A while back, I wrote an article about adding type safety to translations in a TypeScript
project. In this article, we’ll build on that foundation by applying the same concept to a Next.js
app. With the help of Next.js
’s mature ecosystem, we can take things a step further and significantly enhance the developer experience.
Note: This guide is based onNext.js 15
and uses theApp Router
.
Before diving into the implementation, let’s outline what we aim to achieve. These are the key goals:
"The {fullName} is..."
or {count, plural, one {# item} other {# items}}
) should be fully type-safe. If a translation expects a fullName
string or a count
number, the developer should be required to provide exactly that—nothing more, nothing less. This is the core of what type-safe translations are all about, and the ultimate goal of this article.
common
, auth
, billing
). This structure improves clarity and scalability.
The first step is to install the necessary packages and establish the project architecture. We'll begin by installing next-intl
. If you're using yarn
, you can run the command below — but feel free to use your preferred package manager, such as npm
or pnpm
:
yarn add -d next-intl
Next, we need to decide on the folder structure for our translations. There are several common patterns, such as {namespace}/{locale}.(json|yml)
or {locale}/{namespace}.(json|yml)
. In this article, we’ll use the i18n/{locale}/{namespace}.json
format. We’re using .json
because it’s supported out of the box by the library we’ve chosen. If you prefer .yml
, be aware that it may require additional configuration.
Here’s what the structure will look like:
i18n/
├── en-US/
│ ├── common.json
│ └── auth.json
└── fr-FR/
├── common.json
└── auth.json
It’s also common in Next.js
apps to include the locale in the URL, either as a subdomain or a path segment—for example:
{locale}.domain.com/page
domain.com/{locale}/page
However, for the purposes of this article, we won’t be using localized routes.
next-intl
Before configuring next-intl
, let’s define a few helper types. These types will not only be reused across the app, but also help us declare the list of supported locales and namespaces in a single, centralized place.
export const locales = ["en-US", "fr-FR"] as const;
export type Locale = (typeof locales)[number];
export const DEFAULT_LOCALE: Locale = "en-US";
export const i18nNamespaces = ["common", "auth", "language"] as const;
export type I18nNamespace = (typeof i18nNamespaces)[number];
Next, create a file called request.ts
inside the i18n
directory. next-intl
will automatically pick up this file to load translations for a given locale. The file should export a default function that loads the appropriate translation messages. Here's how it looks:
import { getRequestConfig, RequestConfig } from "next-intl/server";
import { DEFAULT_LOCALE, i18nNamespaces, Locale } from "./i18n.types";
import { getUserLocale } from "./util";
export default getRequestConfig(async () => {
const locale = await getUserLocale();
const config: RequestConfig = {
locale,
messages: {},
};
for (const ns of i18nNamespaces) {
if (config.messages) config.messages[ns] = await loadTranslations(locale, ns);
}
return config;
});
const loadTranslations = async (locale: Locale, ns: string) => {
try {
return (await import(`./${locale}/${ns}.json`)).default;
} catch {
return (await import(`./${DEFAULT_LOCALE}/${ns}.json`)).default;
}
};
You can also export a formats
object from this file to define formatting rules for dateTime
, number
, and list
. However, we'll skip that for now as it’s outside the scope of this article.
You’ll notice that the implementation uses a getUserLocale
utility function. This function runs on the server and uses the following logic to determine the user’s locale:
getSettings
function.
Here’s the implementation of getUserLocale
:
import { cookies, headers } from "next/headers";
import { DEFAULT_LOCALE, Locale, locales } from "./i18n.types";
import { COOKIE_LOCALE } from "@/constants/cookie-names";
import { getSettings } from "@/actions/get-settings";
export const isValidLocale = (locale: string): locale is Locale => {
return locales.includes(locale as Locale);
};
export const getUserLocale = async (): Promise<Locale> => {
const _localeCookie = (await cookies()).get(COOKIE_LOCALE)?.value;
const localeFromCookie = locales.find((locale) => locale === _localeCookie);
const [localeFromHeader] = ((await headers())
.get("accept-language")
?.split(",")
.map((lang) => {
const [locale, q = "1"] = lang.trim().split(";q=");
return { locale, q: parseFloat(q) };
})
.filter(({ locale }) => isValidLocale(locale))
.sort((a, b) => b.q - a.q)
.map(({ locale }) => locale) ?? []) as Locale[];
return localeFromCookie || (await getSettings()).locale || localeFromHeader || DEFAULT_LOCALE;
};
In the code above, COOKIE_LOCALE
is a constant string representing the cookie name.getSettings
is a server function that fetches user settings from the database.
For demonstration purposes, here’s a mock version of it that returns static data:
"use server";
import { Locale } from "@/i18n/i18n.types";
type Settings = {
locale: Locale;
};
export const getSettings = async () =>
new Promise<Settings>((resolve) =>
resolve({
locale: "en-US",
})
);
This is where the real magic happens. To enable type safety for translations, we need to configure next-intl
to watch the translation files for our default locale and generate corresponding type declarations. These type definitions ensure correctness and provide auto-completion throughout the app.
Since these files are auto-generated, there's no need to commit them to version control. Add the following pattern to your .gitignore
:
i18n/**/*.d.json.ts
next-intl
in next.config.ts
We’ll use the createNextIntlPlugin
utility and configure it to generate type declarations for the default locale (en-US
in this case):
import { NextConfig } from "next";
import createNextIntlPlugin from "next-intl/plugin";
import { i18nNamespaces } from "./i18n/i18n.types";
const nextConfig = {
... // your next config
} satisfies NextConfig;
const withNextIntl = createNextIntlPlugin({
experimental: {
createMessagesDeclaration: i18nNamespaces.map((ns) => `./i18n/en-US/${ns}.json`),
},
});
export default withNextIntl(nextConfig);
The createMessagesDeclaration
option tells next-intl
which files to scan and generate types for. Since all other locales should follow the same structure, it's enough to type only the default one.
⚠️ Make sure to enable"allowArbitraryExtensions": true
in thecompilerOptions
section of yourtsconfig.json
. This allowsTypeScript
to parse.d.json.ts
files correctly.
Now we need to export the messages object from our default locale in a way that retains its types. Here's an example:
import common from "@/i18n/en-US/common.json";
import auth from "@/i18n/en-US/auth.json";
import language from "@/i18n/en-US/language.json";
import { I18nNamespace } from "./i18n.types";
export const messages = {
common,
auth,
language,
} satisfies { [ns in I18nNamespace]: any };
This approach ensures that all defined namespaces are explicitly included. If you add a new namespace and forget to include it here, TypeScript
will flag it — giving you a clear and immediate signal to update the definition. This helps maintain consistency and improves the developer experience.
Finally, declare the generated types globally so you can use them across your app:
import { Locale } from "../i18n/i18n.types";
import { messages } from "../i18n/message-types";
declare module "next-intl" {
interface AppConfig {
Locale: Locale;
Messages: typeof messages;
}
}
To enable components to access translations, start by setting the lang
attribute on the element in your root
layout.tsx
. Additionally, wrap your page content with the NextIntlClientProvider
from next-intl
. Here's what the layout might look like:
import { getLocale } from "next-intl/server";
import { NextIntlClientProvider } from "next-intl";
export default async function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
const locale = await getLocale();
return (
<html lang={locale}>
<body>
<NextIntlClientProvider>
{children}
</NextIntlClientProvider>
</body>
</html>
);
}
Once the provider is in place, you can use the useTranslations
hook in any component to access localized messages. For example:
import { useTranslations } from "next-intl";
export const MyComponent = () => {
const t = useTranslations();
return (
<>
<div>{t("auth.signUp")}</div>
{/* Other components or content can go here */}
</>
);
};
Now that we’ve achieved type-safe translations, let’s take it a step further by keeping our translation files clean and well-organized. One simple way to do this is by automatically sorting translation keys.
To accomplish this, we’ll write a script that reads each translation file, sorts its keys alphabetically, and saves the updated file. Here’s an example of such a script:
const fs = require("fs");
const path = require("path");
const i18nFolderPath = path.join(__dirname, "../i18n"); // Navigate up to find i18n
// Recursively sort JSON object keys
const sortObjectKeys = (obj) => {
if (Array.isArray(obj)) {
return obj.map(sortObjectKeys);
}
if (typeof obj === "object" && obj !== null) {
return Object.keys(obj)
.sort()
.reduce((sortedObj, key) => {
sortedObj[key] = sortObjectKeys(obj[key]);
return sortedObj;
}, {});
}
return obj;
};
const sortTranslationFiles = (folderPath) => {
fs.readdir(folderPath, (err, locales) => {
if (err) {
console.error("Error reading i18n folder:", err);
return;
}
locales
.filter((locale) => /[a-z]+\-[A-Z]+/g.test(locale))
.forEach((locale) => {
const localePath = path.join(folderPath, locale);
fs.readdir(localePath, (err, files) => {
if (err) {
console.error(`Error reading locale folder ${locale}:`, err);
return;
}
files
.filter((file) => file.endsWith(".json"))
.forEach((file) => {
const filePath = path.join(localePath, file);
fs.readFile(filePath, "utf8", (readErr, data) => {
if (readErr) {
console.error(`Error reading file ${filePath}:`, readErr);
return;
}
try {
const jsonData = JSON.parse(data);
const sortedData = sortObjectKeys(jsonData);
const sortedJson = JSON.stringify(sortedData, null, 2);
fs.writeFile(filePath, sortedJson + "\n", "utf8", (writeErr) => {
if (writeErr) {
console.error(`Error writing file ${filePath}:`, writeErr);
return;
}
console.log(`✅ Sorted keys in ${filePath}`);
});
} catch (parseErr) {
console.error(`Error parsing JSON in ${filePath}:`, parseErr);
}
});
});
});
});
});
};
// Run the script
sortTranslationFiles(i18nFolderPath);
Once the script is in place, simply add it as a step in your CI pipeline to ensure translations stay sorted automatically on every commit or pull request. You can also run it manually before committing.
i18n-ally
VS Code Extension
This step is entirely optional, but highly recommended — it can significantly enhance the developer experience. Once configured correctly, the i18n-ally extension displays the actual translated values inline wherever the t
function is used.
For example, instead of seeing:
t('common.signUp')
you’ll see:
t("Sign Up")
This makes it much easier to understand and verify translations directly in your code.
After installing the extension, update your VS Code settings to include a configuration like this:
{
"i18n-ally.localesPaths": ["./i18n"],
"i18n-ally.enabledFrameworks": ["next-intl"],
"i18n-ally.namespace": true,
"i18n-ally.sortKeys": true,
"i18n-ally.pathMatcher": "{locale}/{namespace}.{ext}",
"i18n-ally.enabledParsers": ["json"],
"i18n-ally.keystyle": "nested",
"i18n-ally.indent": 2,
"i18n-ally.tabStyle": "space",
"i18n-ally.displayLanguage": "en-US",
"i18n-ally.sourceLanguage": "en-US",
}
You can also add "lokalise.i18n-ally"
to your extensions.json
to recommend it to your team.
That’s it — you're all set!
Thanks for reading. I hope this guide helped you build a more type-safe and developer-friendly internationalization setup in Next.js
.